JVM 自动内存管理机制

运行时数据区域

  1. 程序计数器
    记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。

  2. Java 虚拟机栈
    每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
    可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小,在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M:
    该区域可能抛出以下异常:

    • 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
    • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。
  3. 本地方法栈
    本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。


  4. 所有对象都在这里分配内存,是垃圾收集的主要区域(”GC 堆”)。
    现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:

    • 新生代(Young Generation)
    • 老年代(Old Generation)
      堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。
      可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
  5. 方法区(永久代)
    用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
    和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。
    为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
    方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。在 JDK 1.8 之后,原来永久代的数据被分到了堆和元空间中。元空间存储类的元信息,静态变量和常量池等放入堆中。

  6. 运行时常量池
    运行时常量池是方法区的一部分。
    Class 文件中的常量池(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。

  7. 直接内存
    在 JDK 1.4 中新引入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存

垃圾收集

垃圾收集主要是针对堆和方法区进行。

判断一个对象是否可被回收

  1. 引用计数算法
    为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
    正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。

  2. 可达性分析算法
    以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
    Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

    • 虚拟机栈中局部变量表中引用的对象
    • 本地方法栈中 JNI 中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中的常量引用的对象
  3. 方法区的回收
    主要是对常量池的回收和对类的卸载。
    为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
    类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

    • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
    • 加载该类的 ClassLoader 已经被回收。
    • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

垃圾收集算法

  1. 标记 - 清除
    在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。
    回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。
    在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。
    不足:

    • 标记和清除过程效率都不高;
    • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
  2. 标记 - 整理
    让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
    优点: 不会产生内存碎片
    不足: 需要移动大量对象,处理效率比较低。

  3. 复制
    将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
    主要不足是只使用了内存的一半。
    现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor(?还是只使用 Eden)。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
    HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

  4. 分代收集
    现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
    一般将堆分为新生代和老年代。
    新生代使用:复制算法
    老年代使用:标记 - 清除 或者 标记 - 整理 算法

垃圾收集器

新生代:Serial、ParNew、Parallel Scavenge;
老年代:Serial Old、Parallel Old、CMS; G1 收集器
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

  1. Serial 收集器
    Serial 翻译为串行,也就是说它以串行的方式执行。
    它是单线程的收集器,只会使用一个线程进行垃圾收集工作。它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。

  2. ParNew 收集器
    它是 Serial 收集器的多线程版本。它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。

  3. Parallel Scavenge 收集器
    与 ParNew 一样是多线程收集器。
    其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。
    缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
    可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

  4. Serial Old 收集器
    是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:

    • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
    • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
  5. Parallel Old 收集器
    是 Parallel Scavenge 收集器的老年代版本。
    在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

  6. CMS 收集器
    CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。
    分为以下四个流程:

    • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
    • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
    • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
    • 并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
具有以下缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
  1. G1 收集器
    G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存(如果你的应用使用了 6GB 及以上的堆)的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。
    G1 可以直接对新生代和老年代一起回收。
    G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。
    G1 收集器的运作大致可划分为以下几个步骤:初始标记、并发标记、最终标记、筛选回收
    具备如下特点:
    • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
    • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

内存分配与回收策略

Minor GC 和 Full GC

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Major GC: 老年代的垃圾回收。
  • Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。

内存分配策略

  1. 对象优先在 Eden 分配
    大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
  2. 大对象直接进入老年代
    大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
    经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

  1. 长期存活的对象进入老年代
    为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

  1. 动态对象年龄判定
    虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
  2. 空间分配担保

Full GC 的触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

  1. 调用 System.gc()
    只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
  2. 老年代空间不足
    老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。
  3. 空间分配担保失败
    使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。具体内容请参考上面的第 5 小节。
  4. JDK 1.7 及以前的永久代空间不足
    当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
  5. Concurrent Mode Failure

meituan-从实际案例看GC优化

原文链接:从实际案例聊聊Java应用的GC优化

活跃数据

活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。
活跃数据和各分区之间的比例关系如下图。这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。

确定系统需求

一般应用程序主要关注高可用和低延迟两项指标,需要量化GC时间和频率对于响应时间和可用性的影响。
举例:假设单位时间T内发生一次持续25ms的GC,接口平均响应时间为50ms,且请求均匀到达。
那么有(50ms+25ms)/T比例的请求会受GC影响,其中GC前的50ms内到达的请求都会增加25ms,GC期间的25ms内到达的请求,会增加0-25ms不等,如果时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T

案例一 Major GC和Minor GC频繁

首先Minor GC频繁通常是新生代太小导致的。扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?
对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。
小结(需要结合GC日志分析服务中对象的生命周期分布)
如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

案例二 请求高峰期发生GC,导致服务可用性下降

GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。
Remark 阶段是以新生代中对象为根来判断对象是否存活的。新生代GC和老年代的GC是各自分开独立进行的。
在Minor GC发生前,新生代灰色对象不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。
CMS 在 Remark 前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。
提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。

案例三 发生Stop-The-World的GC

小结
对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致,Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。

9种常见的 CMS GC 问题分析与解决

原文链接:9种常见的 CMS GC 问题分析与解决

meituan-sb 堆外内存泄漏排查

原文链接:Spring Boot引起的“堆外内存泄漏”排查及经验总结